import { useRouter } from "next/router"; import { useEffect, useMemo } from "react"; import { NumberParam, StringParam, useQueryParam, useQueryParams, withDefault, } from "use-query-params"; import { DataTableToolbar } from "@/src/components/table/data-table-toolbar"; import { DataTable } from "@/src/components/table/data-table"; import TableLink from "@/src/components/table/table-link"; import { type LangfuseColumnDef } from "@/src/components/table/types"; import { Skeleton } from "@/src/components/ui/skeleton"; import { useQueryFilterState } from "@/src/features/filters/hooks/useFilterState"; import { useDetailPageLists } from "@/src/features/navigate-detail-pages/context"; import { useV4Beta } from "@/src/features/events/hooks/useV4Beta"; import { api } from "@/src/utils/api"; import { compactNumberFormatter, usdFormatter } from "@/src/utils/numbers"; import { type RouterOutput } from "@/src/utils/types"; import { type FilterState, usersTableCols } from "@langfuse/shared"; import { joinTableCoreAndMetrics } from "@/src/components/table/utils/joinTableCoreAndMetrics"; import { useTableDateRange } from "@/src/hooks/useTableDateRange"; import { toAbsoluteTimeRange } from "@/src/utils/date-range-utils"; import { useDebounce } from "@/src/hooks/useDebounce"; import Page from "@/src/components/layouts/page"; import { UsersOnboarding } from "@/src/components/onboarding/UsersOnboarding"; import { useEnvironmentFilter, convertSelectedEnvironmentsToFilter, } from "@/src/hooks/use-environment-filter"; import { Badge } from "@/src/components/ui/badge"; type RowData = { userId: string; environment?: string; firstEvent: string; lastEvent: string; totalEvents: string; totalTokens: string; totalCost: string; }; export default function UsersPage() { const router = useRouter(); const projectId = router.query.projectId as string; const { isBetaEnabled } = useV4Beta(); // Check if the user has any users const { data: hasAnyUser, isLoading } = api.users.hasAny.useQuery( { projectId }, { enabled: !!projectId && !isBetaEnabled, trpc: { context: { skipBatch: true, }, }, refetchInterval: 10_000, }, ); const { data: hasAnyUserFromEvents, isLoading: isLoadingFromEvents } = api.users.hasAnyFromEvents.useQuery( { projectId }, { enabled: !!projectId && isBetaEnabled, trpc: { context: { skipBatch: true, }, }, refetchInterval: 10_000, }, ); const hasUsers = isBetaEnabled ? hasAnyUserFromEvents : hasAnyUser; const isLoadingUsers = isBetaEnabled ? isLoadingFromEvents : isLoading; const showOnboarding = !isLoadingUsers && !hasUsers; return ( {/* Show onboarding screen if user has no users */} {showOnboarding ? ( ) : ( )} ); } const UsersTable = ({ isBetaEnabled }: { isBetaEnabled: boolean }) => { const router = useRouter(); const projectId = router.query.projectId as string; const [userFilterState, setUserFilterState] = useQueryFilterState( [], "users", projectId, ); const { setDetailPageList } = useDetailPageLists(); const [paginationState, setPaginationState] = useQueryParams({ pageIndex: withDefault(NumberParam, 0), pageSize: withDefault(NumberParam, 50), }); const { timeRange, setTimeRange } = useTableDateRange(projectId); // Convert timeRange to absolute date range for compatibility const dateRange = useMemo(() => { return toAbsoluteTimeRange(timeRange) ?? undefined; }, [timeRange]); const dateRangeFilter: FilterState = dateRange ? [ { column: "Timestamp", type: "datetime", operator: ">=", value: dateRange.from, }, { column: "Timestamp", type: "datetime", operator: "<=", value: dateRange.to, }, ] : []; const environmentFilterOptions = api.projects.environmentFilterOptions.useQuery( { projectId, fromTimestamp: dateRange?.from, }, { trpc: { context: { skipBatch: true } }, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, staleTime: Infinity, }, ); const environmentOptions = environmentFilterOptions.data?.map((value) => value.environment) || []; const { selectedEnvironments, setSelectedEnvironments } = useEnvironmentFilter(environmentOptions, projectId); const environmentFilter = convertSelectedEnvironmentsToFilter( ["environment"], selectedEnvironments, ); const filterState = userFilterState.concat( dateRangeFilter, environmentFilter, ); const [searchQuery, setSearchQuery] = useQueryParam( "search", withDefault(StringParam, null), ); // Legacy API calls (traces-based) const usersLegacy = api.users.all.useQuery( { filter: filterState, page: paginationState.pageIndex, limit: paginationState.pageSize, projectId, searchQuery: searchQuery ?? undefined, }, { enabled: !isBetaEnabled }, ); const userMetricsLegacy = api.users.metrics.useQuery( { projectId, userIds: usersLegacy.data?.users.map((u) => u.userId) ?? [], filter: filterState, }, { enabled: usersLegacy.isSuccess && !isBetaEnabled, trpc: { context: { skipBatch: true, }, }, }, ); // Beta API calls (events-based) const usersBeta = api.users.allFromEvents.useQuery( { filter: filterState, page: paginationState.pageIndex, limit: paginationState.pageSize, projectId, searchQuery: searchQuery ?? undefined, }, { enabled: isBetaEnabled }, ); const userMetricsBeta = api.users.metricsFromEvents.useQuery( { projectId, userIds: usersBeta.data?.users.map((u) => u.userId) ?? [], filter: filterState, }, { enabled: usersBeta.isSuccess && isBetaEnabled, trpc: { context: { skipBatch: true, }, }, }, ); // Select the active query based on beta state const users = isBetaEnabled ? usersBeta : usersLegacy; const userMetrics = isBetaEnabled ? userMetricsBeta : userMetricsLegacy; type UserCoreOutput = RouterOutput["users"]["all"]["users"][number]; type UserMetricsOutput = RouterOutput["users"]["metrics"][number]; type CoreType = Omit & { id: string }; type MetricType = Omit & { id: string }; const userRowData = joinTableCoreAndMetrics( users.data?.users.map((u) => ({ ...u, id: u.userId, })), userMetrics.data?.map((u) => ({ ...u, id: u.userId, })), ); const totalCount = users.data?.totalUsers ? Number(users.data.totalUsers) : null; useEffect(() => { if (users.isSuccess) { setDetailPageList( "users", users.data.users.map((u) => ({ id: encodeURIComponent(u.userId) })), ); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [users.isSuccess, users.data]); const columns: LangfuseColumnDef[] = [ { accessorKey: "userId", enableColumnFilter: true, header: "User ID", headerTooltip: { description: "The unique identifier for the user that was logged in Langfuse. See docs for more details on how to set this up.", href: "https://langfuse.com/docs/observability/features/users", }, size: 150, cell: ({ row }) => { const value: RowData["userId"] = row.getValue("userId"); return typeof value === "string" ? ( <> ) : undefined; }, }, { accessorKey: "environment", header: "Environment", id: "environment", size: 150, enableHiding: true, cell: ({ row }) => { const value: RowData["environment"] = row.getValue("environment"); return value ? ( {value} ) : null; }, }, { accessorKey: "firstEvent", header: "First Event", headerTooltip: { description: "The earliest trace recorded for this user.", }, size: 150, cell: ({ row }) => { const value: RowData["firstEvent"] = row.getValue("firstEvent"); if (!userMetrics.isSuccess) { return ; } return typeof value === "string" ? value : undefined; }, }, { accessorKey: "lastEvent", header: "Last Event", headerTooltip: { description: "The latest trace recorded for this user.", }, size: 150, cell: ({ row }) => { const value: RowData["lastEvent"] = row.getValue("lastEvent"); if (!userMetrics.isSuccess) { return ; } return typeof value === "string" ? value : undefined; }, }, { accessorKey: "totalEvents", header: "Total Events", headerTooltip: { description: "Total number of events for the user, includes traces and observations. See data model for more details.", href: "https://langfuse.com/docs/observability/data-model", }, size: 120, cell: ({ row }) => { const value: RowData["totalEvents"] = row.getValue("totalEvents"); if (!userMetrics.isSuccess) { return ; } return typeof value === "string" ? value : undefined; }, }, { accessorKey: "totalTokens", header: "Total Tokens", headerTooltip: { description: "Total number of tokens used for the user across all generations.", href: "https://langfuse.com/docs/model-usage-and-cost", }, size: 120, cell: ({ row }) => { const value: RowData["totalTokens"] = row.getValue("totalTokens"); if (!userMetrics.isSuccess) { return ; } return typeof value === "string" ? value : undefined; }, }, { accessorKey: "totalCost", header: "Total Cost", headerTooltip: { description: "Total cost for the user across all generations.", href: "https://langfuse.com/docs/model-usage-and-cost", }, size: 120, cell: ({ row }) => { const value: RowData["totalCost"] = row.getValue("totalCost"); if (!userMetrics.isSuccess) { return ; } return typeof value === "string" ? value : undefined; }, }, ]; return ( <> ({ value: env })), }} /> { return { userId: t.id, environment: t.environment ?? undefined, firstEvent: t.firstTrace?.toLocaleString() ?? "No event yet", lastEvent: t.lastTrace?.toLocaleString() ?? "No event yet", totalEvents: compactNumberFormatter( isBetaEnabled ? Number(t.totalObservations ?? 0) : Number(t.totalTraces ?? 0) + Number(t.totalObservations ?? 0), ), totalTokens: compactNumberFormatter(t.totalTokens ?? 0), totalCost: usdFormatter( t.sumCalculatedTotalCost ?? 0, 2, 2, ), }; }), } } pagination={{ totalCount, onChange: setPaginationState, state: paginationState, }} /> ); };